agentmux_srv\backend\storage/
memory_bundles.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Memory bundles — the agent's personality and capability stack
5//! (provider, model, instructions, context files, MCP servers, skills).
6//!
7//! Extracted from `store.rs` in Phase R.3 of the storage
8//! modularization plan
9//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
10//! method surface is unchanged — `Store::bundle_memory_*` still
11//! lives on `Store` via this `impl` block; callers stay on
12//! `storage::store::Memory` thanks to the re-export.
13
14use rusqlite::params;
15use serde::{Deserialize, Serialize};
16
17use super::error::StoreError;
18use super::store::Store;
19
20/// A Memory bundle — the agent's personality and capability stack.
21/// Provider, model, instructions, and JSON-encoded arrays of context
22/// files / MCP servers / skills. Agent definitions shadow-migrate into this
23/// table during the v7 migration.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Memory {
26    pub id: String,
27    pub name: String,
28    #[serde(default)]
29    pub description: String,
30    #[serde(default)]
31    pub is_blank: bool,
32    /// Global bundles are injected into every agent's CLAUDE.md at launch,
33    /// regardless of per-agent memory selection. Managed in the Trust Center
34    /// (Identity & Memory hamburger modal). Seeded from workspace-wide rule sets.
35    #[serde(default)]
36    pub is_global: bool,
37    /// "claude" | "codex" | "gemini" | empty string
38    #[serde(default)]
39    pub provider: String,
40    #[serde(default)]
41    pub model: String,
42    #[serde(default)]
43    pub instructions: String,
44    /// JSON-encoded array; the renderer types it as `[{path, content}]`.
45    #[serde(default = "default_json_array_string")]
46    pub context_files: String,
47    /// JSON-encoded array of MCP server configs.
48    #[serde(default = "default_json_array_string")]
49    pub mcp_servers: String,
50    /// JSON-encoded array of skill IDs.
51    #[serde(default = "default_json_array_string")]
52    pub skills: String,
53    /// Explicit ordering within the Trust Center global brain. Lower sorts
54    /// first; this is the order sections inject into CLAUDE.md at launch.
55    /// Only meaningful for `is_global` bundles; 0 for the rest. Owned by the
56    /// `reorderglobalbrain` RPC — `bundle_memory_upsert` never overwrites it
57    /// on conflict, so editing a bundle via the regular form keeps its place.
58    #[serde(default)]
59    pub sort_order: i64,
60    // created_at / updated_at are server-owned: the upsert handler stamps
61    // created_at = now when 0 and always overwrites updated_at with now. They
62    // default on input so partial upserts (e.g. a "new section" that only
63    // sends id/name/instructions) deserialize cleanly. (reagent P0 on #1608)
64    #[serde(default)]
65    pub created_at: i64,
66    #[serde(default)]
67    pub updated_at: i64,
68}
69
70fn default_json_array_string() -> String {
71    "[]".to_string()
72}
73
74/// Format global brain bundles into the block injected into an agent's
75/// CLAUDE.md. Each non-empty section gets a `# [Workspace] <name>` heading so
76/// Claude can tell injected workspace rules apart from the agent's own config;
77/// sections are separated by a `---` rule. Bundles arrive already ordered by
78/// `bundle_memory_list_global` (sort_order). Returns an empty string when no
79/// section has instructions.
80pub fn format_global_brain_block(bundles: &[Memory]) -> String {
81    bundles
82        .iter()
83        .filter(|b| !b.instructions.trim().is_empty())
84        .map(|b| format!("# [Workspace] {}\n\n{}", b.name, b.instructions))
85        .collect::<Vec<_>>()
86        .join("\n\n---\n\n")
87}
88
89impl Store {
90    pub fn bundle_memory_list(&self) -> Result<Vec<Memory>, StoreError> {
91        let conn = self.conn.lock().unwrap();
92        let mut stmt = conn.prepare(
93            "SELECT id, name, description, is_blank, is_global, provider, model, instructions,
94                    context_files, mcp_servers, skills, sort_order, created_at, updated_at
95             FROM db_memory_bundles
96             ORDER BY is_blank ASC, is_global DESC, updated_at DESC",
97        )?;
98        let iter = stmt.query_map([], map_memory_row)?;
99        let mut out = Vec::new();
100        for r in iter {
101            out.push(r?);
102        }
103        Ok(out)
104    }
105
106    /// Returns only the global bundles (`is_global = 1`), in explicit
107    /// `sort_order` (then name as a stable tiebreak). Called at agent launch
108    /// to inject workspace-wide rules into every agent in the order the user
109    /// arranged them in the Trust Center Brain tab.
110    pub fn bundle_memory_list_global(&self) -> Result<Vec<Memory>, StoreError> {
111        let conn = self.conn.lock().unwrap();
112        let mut stmt = conn.prepare(
113            "SELECT id, name, description, is_blank, is_global, provider, model, instructions,
114                    context_files, mcp_servers, skills, sort_order, created_at, updated_at
115             FROM db_memory_bundles
116             WHERE is_global = 1
117             ORDER BY sort_order ASC, name ASC",
118        )?;
119        let iter = stmt.query_map([], map_memory_row)?;
120        let mut out = Vec::new();
121        for r in iter {
122            out.push(r?);
123        }
124        Ok(out)
125    }
126
127    pub fn bundle_memory_get(&self, id: &str) -> Result<Option<Memory>, StoreError> {
128        let conn = self.conn.lock().unwrap();
129        let mut stmt = conn.prepare(
130            "SELECT id, name, description, is_blank, is_global, provider, model, instructions,
131                    context_files, mcp_servers, skills, sort_order, created_at, updated_at
132             FROM db_memory_bundles WHERE id = ?1",
133        )?;
134        let result = stmt.query_row(params![id], map_memory_row);
135        match result {
136            Ok(m) => Ok(Some(m)),
137            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
138            Err(e) => Err(e.into()),
139        }
140    }
141
142    pub fn bundle_memory_upsert(&self, memory: &Memory) -> Result<(), StoreError> {
143        let conn = self.conn.lock().unwrap();
144        conn.execute(
145            // sort_order is deliberately NOT in the ON CONFLICT update set:
146            // it is owned by `bundle_memory_reorder`, so editing a bundle
147            // through the regular Memory form never disturbs its position in
148            // the global brain.
149            "INSERT INTO db_memory_bundles
150                (id, name, description, is_blank, is_global, provider, model, instructions,
151                 context_files, mcp_servers, skills, sort_order, created_at, updated_at)
152             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
153             ON CONFLICT(id) DO UPDATE SET
154                name = excluded.name,
155                description = excluded.description,
156                is_global = excluded.is_global,
157                provider = excluded.provider,
158                model = excluded.model,
159                instructions = excluded.instructions,
160                context_files = excluded.context_files,
161                mcp_servers = excluded.mcp_servers,
162                skills = excluded.skills,
163                updated_at = excluded.updated_at",
164            params![
165                memory.id,
166                memory.name,
167                memory.description,
168                memory.is_blank as i64,
169                memory.is_global as i64,
170                memory.provider,
171                memory.model,
172                memory.instructions,
173                memory.context_files,
174                memory.mcp_servers,
175                memory.skills,
176                memory.sort_order,
177                memory.created_at,
178                memory.updated_at,
179            ],
180        )?;
181        Ok(())
182    }
183
184    /// Delete a Memory bundle. Refuses to delete the blank singleton.
185    pub fn bundle_memory_delete(&self, id: &str) -> Result<bool, StoreError> {
186        if id == "blank" {
187            return Err(StoreError::Other(
188                "cannot delete the blank Memory singleton".to_string(),
189            ));
190        }
191        // Seeded bundles (IDs prefixed "seed-") are workspace defaults that
192        // re-seed on every startup; blocking deletion is cleaner than a
193        // tombstone table and avoids the re-creation loop.
194        if id.starts_with("seed-") {
195            return Err(StoreError::Other(
196                "cannot delete a seeded Memory bundle; toggle is_global or clear its instructions instead".to_string(),
197            ));
198        }
199        let conn = self.conn.lock().unwrap();
200        let rows = conn.execute("DELETE FROM db_memory_bundles WHERE id = ?1", params![id])?;
201        Ok(rows > 0)
202    }
203
204    /// Assign `sort_order` to the given bundle ids in the order supplied
205    /// (position 0, 1, 2, …). Drives the Trust Center global brain ordering,
206    /// which in turn controls CLAUDE.md injection order. Ids not present in
207    /// the table are skipped silently (a concurrently-deleted section is not
208    /// an error). Runs in a single transaction so a partial reorder never
209    /// lands. Returns the number of rows updated.
210    pub fn bundle_memory_reorder(&self, ordered_ids: &[String]) -> Result<usize, StoreError> {
211        let mut conn = self.conn.lock().unwrap();
212        let tx = conn.transaction()?;
213        let mut updated = 0usize;
214        {
215            let mut stmt =
216                tx.prepare("UPDATE db_memory_bundles SET sort_order = ?1 WHERE id = ?2")?;
217            for (idx, id) in ordered_ids.iter().enumerate() {
218                updated += stmt.execute(params![idx as i64, id])?;
219            }
220        }
221        tx.commit()?;
222        Ok(updated)
223    }
224}
225
226fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
227    Ok(Memory {
228        id: row.get(0)?,
229        name: row.get(1)?,
230        description: row.get(2)?,
231        is_blank: row.get::<_, i64>(3)? != 0,
232        is_global: row.get::<_, i64>(4)? != 0,
233        provider: row.get(5)?,
234        model: row.get(6)?,
235        instructions: row.get(7)?,
236        context_files: row.get(8)?,
237        mcp_servers: row.get(9)?,
238        skills: row.get(10)?,
239        sort_order: row.get(11)?,
240        created_at: row.get(12)?,
241        updated_at: row.get(13)?,
242    })
243}